Skip to content

feat(core): Encrypt secret custom fields and config args at rest - #5051

Merged
michaelbromley merged 44 commits into
minorfrom
feat/secret-fields
Jul 31, 2026
Merged

feat(core): Encrypt secret custom fields and config args at rest#5051
michaelbromley merged 44 commits into
minorfrom
feat/secret-fields

Conversation

@michaelbromley

@michaelbromley michaelbromley commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a secret: true flag for custom fields and configurable-operation args. Values marked secret are encrypted at rest in the database and only returned in decrypted form via the API to callers permitted to see them (by default, holders of the new ReadSecret permission); everyone else receives a redaction placeholder.

Fixes #2648

How it works

Encryption at rest. A configurable EncryptionStrategy (default: DefaultEncryptionStrategy, AES-256-GCM) encrypts secret values before they are stored. Custom-field values are encrypted via a TypeORM value transformer; config-arg values are encrypted centrally in ConfigArgService.parseInput. Ciphertext carries a versioned enc:v1: prefix, and legacy plaintext is passed through unchanged so enabling secret on existing data does not break it.

Gating decrypted access, centrally. Rather than wiring redaction into each resolver, a single field resolver on the shared ConfigurableOperation GraphQL type gates the args of every configurable operation in the API — core or custom, on core or custom entities — with no per-operation-type wiring. Custom-field values are gated in the generic custom-field resolver. Both consult a SecretAccessStrategy (default: PermissionSecretAccessStrategy, which requires ReadSecret). Its input is a discriminated union on kind (customField | configArg), so each context carries only the information actually available to it.

Editing without re-entering the secret. Because the API returns a placeholder, submitting that placeholder back on an update preserves the stored value; submitting a new value replaces it; on a create the placeholder is rejected.

Bootstrap safety.

  • If a secret field/arg is configured but no usable key is available, the server refuses to start, and the error shows the exact config to add.
  • The first time a key is used against a database, a key-check value is stored via the settings store and verified on every startup. If the configured key later differs (e.g. a database was restored into an environment with a different key), the server fails to start with a clear message instead of failing later with scattered decryption errors.

Config-driven secret. The strategy does not read process.env itself. Following the framework convention, the secret is read in the config and passed in explicitly:

systemOptions: {
    encryptionStrategy: new DefaultEncryptionStrategy({ secret: process.env.VENDURE_ENCRYPTION_KEY }),
}

Dashboard

Secret custom fields and secret configurable-operation args render by default as a masked, revealable password input in the Dashboard, so it is visually clear that the value is sensitive even to a user who is permitted to read it. The same masking is applied to the read-only value summaries, so a redacted or decrypted secret is never shown in plain text there either. An explicit ui.component override still takes precedence over this default.

The masked input reuses the existing password-form-input component, and the default is resolved centrally in resolveInputComponentId, shared between the form control and the operation summary. For this to work the Dashboard's custom-field and config-arg definition queries now select the secret flag.

When the current user is not permitted to read a secret, the API returns the redaction placeholder rather than the value. The Dashboard recognises this placeholder and renders the field as a read-only "Hidden" input with no reveal control, so the internal placeholder is never shown to the user. The placeholder remains the field's form value, so saving the entity still round-trips it and preserves the stored secret unchanged.

Security hardening

An adversarial review of the design surfaced several issues, now addressed:

  • Key-check exposure. The bootstrap key-check settings-store entry was readable by any authenticated user, which handed out an offline brute-force oracle for the secret. Read access is now restricted to the SuperAdmin.
  • Config-arg fail-open. The systemic resolver redacted based on whether the stored value looked encrypted, so a secret arg holding legacy plaintext leaked to callers without ReadSecret. Redaction is now driven by the arg's secret definition, so such values are redacted regardless of their stored form.
  • Weak key derivation. Key derivation moved from a single unsalted SHA-256 to scrypt (memory-hard), and a startup warning is emitted for a short/low-entropy secret.

Known follow-up (not in this PR): binding each ciphertext to its location with AAD would prevent an attacker with database write access from transplanting one secret's ciphertext into another field. Doing this at row-level granularity requires moving encryption into a layer that knows the entity's identity, so it is left as a separate hardening task.

Limitations

  • Key rotation (re-encrypting existing data under a new key) is not supported; the key must remain stable. Moving data between environments therefore requires the same key, or scrubbing the secret columns in the dump. This is documented in the new guide.
  • Decrypted values are available to all server-side code (events, jobs, plugins); redaction applies only to the GraphQL API. Take care not to re-leak secrets via logs, exports, job payloads or the search index.

Testing

  • New unit specs for the encryption strategy (round-trip, random IV, legacy passthrough, key mismatch, isEncrypted, does-not-read-env) and the key-check verifier (first-boot write, same-key pass, different-key fail, graceful skip).
  • New e2e coverage for secret custom fields and secret config args, including a cross-operation-type case (collection filter) proving the systemic redaction works for a type with no bespoke wiring, and a legacy-plaintext redaction case.
  • Full related e2e sweep (collection, promotion, shipping, payment method, configurable operation, entity duplicator, custom fields) passes.

Docs

New "Secret fields" developer guide covering configuration, the access strategy, editing behaviour, moving data between environments, and limitations.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Introduce the EncryptionStrategy (with an AES-256-GCM default keyed from the
VENDURE_ENCRYPTION_KEY environment variable) and the SecretAccessStrategy
(defaulting to a new Permission.ReadSecret check) which together underpin the
`secret` field feature. Both are configured via systemOptions and initialised
during bootstrap, and the server fails fast if a secret field is configured
without a usable encryption key.

Relates to #2648
…s at rest

A custom field or config arg marked `secret: true` has its value encrypted via
the configured EncryptionStrategy before being stored, and decrypted when
loaded. Custom-field secrets use a TypeORM value transformer and are stored as
unbounded text; config-arg secrets are encrypted in ConfigArgService.parseInput
and decrypted in argsArrayToHash. When the API returns a redaction placeholder
for a secret, submitting that placeholder back on an update preserves the
stored value; on a create it is rejected.

Relates to #2648
…values

The decrypted value of a `secret` custom field or config arg is only returned
to callers permitted by the SecretAccessStrategy (by default, holders of
Permission.ReadSecret); everyone else receives a redaction placeholder. This
adds field resolvers for PaymentMethod and ShippingMethod handler/checker
operations, which previously returned the raw stored value, and applies the
same rule to custom field resolvers. Secret custom fields are also excluded
from the generated filter and sort inputs, since their stored value is
ciphertext.

Relates to #2648
Verify that secret values are stored encrypted, are returned decrypted only to
ReadSecret holders and redacted otherwise, are preserved when the placeholder
is submitted on update, and are rejected when the placeholder is submitted on
create. Also updates the administrator snapshot for the new ReadSecret
permission.

Relates to #2648
Secret config arg handling was wired per-resolver and only covered PaymentMethod
and ShippingMethod, so a secret arg on a collection filter or promotion
condition/action returned raw ciphertext to any reader and double-encrypted on
edit.

Move redaction to a single ConfigurableOperation.args field resolver that gates
every configurable operation in the API, keyed on the self-identifying ciphertext
prefix, so any operation type (core or custom) is covered with no per-resolver
wiring. Thread previously-stored values into the promotion and collection update
paths so a resubmitted placeholder is preserved. Replace SecretAccessInput with a
discriminated union on kind (customField | configArg) and validate that secret
config args are non-list strings at bootstrap.

Relates to #2648
Store a known value encrypted with the active key the first time an encryption
key is used against a database, and verify it on every startup. If the configured
key no longer matches (e.g. a database was restored into an environment with a
different VENDURE_ENCRYPTION_KEY, or the key was changed), the server fails to
start with a clear error instead of failing later with scattered runtime
decryption errors. The check uses the settings store, so no schema change is
required, and is purely additive: if the store cannot be read it is skipped.

Relates to #2648
The DefaultEncryptionStrategy no longer reads VENDURE_ENCRYPTION_KEY from the
environment directly. Following Vendure's convention that environment variables
are read in the config and passed into strategies, the secret must be provided
explicitly via the strategy's `secret` option. When a secret field or arg is
configured without a usable key, the bootstrap error now shows the exact config
to add.

Relates to #2648
The bootstrap key-check value was registered in the settings store without a read
permission, so any authenticated user could fetch it via getSettingsStoreValue.
Because it is a known plaintext encrypted with the active key, that gave an offline
brute-force oracle for the encryption secret. Restrict read access to the SuperAdmin,
who already has full access to decrypted secrets.

Relates to #2648
The systemic resolver redacted a config arg only when its stored value looked
encrypted. A secret arg holding a plaintext value — e.g. data written before the
field was marked secret, or via a raw insert — was served in the clear to any
caller with the ordinary read permission, bypassing ReadSecret. Redact based on
the arg's `secret` definition instead, so such values are redacted (and only
decrypted, or returned as-is when legacy plaintext, for a ReadSecret holder).

Relates to #2648
Key derivation used a single unsalted SHA-256 of the secret, which is cheap to
brute-force offline for a weak or low-entropy secret. Switch to scrypt, a
memory-hard KDF, so each guess is expensive, and warn at startup when the secret
is shorter than the recommended length. The salt is a fixed application constant,
since the key must be derived synchronously at bootstrap.

Relates to #2648
@vendure-developer-hub

vendure-developer-hub Bot commented Jul 29, 2026

Copy link
Copy Markdown

Docs previewPR merged

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview Jul 31, 2026 8:28am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d643bf2c-6919-4f66-b8e8-21aab28ca62b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Dashboard Preview: https://admin-dashboard-kc91cqgxs-vendure.vercel.app

@michaelbromley michaelbromley added this to the v3.8.0 milestone Jul 29, 2026

@dlhck dlhck left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for four correctness and authorization issues in the secret-field implementation.

  1. Repeated configurable operations are matched to previous values only by code. Duplicate promotion conditions/actions and collection filters can therefore preserve the first instance’s encrypted secret into later instances. Preserve by list position or stable instance identity.

  2. SecretAccessStrategy receives the custom-fields wrapper rather than the documented owning VendureEntity, preventing reliable entity-aware authorization.

  3. secret: true is silently accepted on relation custom fields because validation occurs only in the non-relation branch; such fields are neither encrypted nor redacted.

  4. Legacy plaintext is passed unconditionally to EncryptionStrategy.decrypt() in custom-field transformers and configurable-operation execution, although the public strategy contract only requires decrypting values produced by encrypt(). Check isEncrypted() first.

Comment thread packages/core/src/service/services/promotion.service.ts Outdated
Comment thread packages/core/src/api/config/generate-resolvers.ts Outdated
Comment thread packages/core/src/entity/register-custom-entity-fields.ts
Comment thread packages/core/src/entity/value-transformers.ts Outdated
Repeated configurable operations (duplicate promotion conditions/actions or
collection filters sharing a code) were matched to their previous value by code
alone, so every placeholder-bearing entry preserved the first entry's secret and
overwrote the others. Match by code and by position among same-code entries so
each preserves its own value.

Relates to #2648
…fields

The custom-field secret resolver passed the customFields wrapper object instead
of the owning VendureEntity, breaking the documented SecretAccessInput contract
and preventing entity-aware authorization. Retain and pass the actual entity.

Relates to #2648
The secret field-type validation ran only in the non-relation branch, so a
relation custom field with secret:true was silently registered without encryption
or redaction. Validate the type and list constraints before branching so
unsupported types are rejected at registration.

Relates to #2648
Custom-field transformers and configurable-operation execution called decrypt()
unconditionally, including on legacy plaintext, although the strategy contract only
requires decrypt() to handle values produced by encrypt(). Check isEncrypted()
first and return plaintext unchanged otherwise, so custom strategies need not
tolerate non-ciphertext and enabling secret on existing data keeps working.

Relates to #2648
…r vendure.

Use 'vendure.encryption.keyCheck' rather than 'encryption.keyCheck', matching the
framework convention for internal settings store keys (e.g. vendure.dashboard.*).

Relates to #2648
A secret custom field or config arg with no explicit ui.component now renders as a
masked (revealable) password input in the dashboard, and its read-only summary is
masked too, so it is visually clear the value is sensitive even for a user permitted
to read it. An explicit ui.component still takes precedence.

Relates to #2648
The masked password input for secret fields relied on `fieldDef.secret`, but
the dashboard's custom-field config and config-arg definition queries did not
select `secret`, so it was always undefined and the input rendered as plain
text. Add `secret` to both fragments and to the gql.tada schema types.

Relates to #2648
Extract the 'secret fields default to the masked password input' logic into a
single `resolveInputComponentId` helper used by both the form control and the
configurable-operation summary, replacing the duplicated `secret` casts now that
the field config types expose `secret` directly.

Also forward `onBlur` and `name` from PasswordFormInput, so secret fields routed
through it keep blur-driven validation and the input name attribute.

Relates to #2648
When a user without permission to read a secret opened the field, the API's
redaction placeholder (an internal round-trip sentinel) was shown in the input
and, on reveal, displayed in plain text. Such a value is now rendered as a
read-only "Hidden" field with no reveal toggle; the placeholder remains the form
value so saving the entity still round-trips it and preserves the stored secret.

The prefix used to detect the placeholder is inlined rather than imported from
@vendure/common, whose CommonJS build cannot be imported as a runtime value in
the browser bundle; a unit test asserts it against the real constant to prevent
drift.

Relates to #2648
Comment thread docs/docs/guides/developer-guide/secret-fields/index.mdx Outdated
Comment thread docs/docs/guides/developer-guide/secret-fields/index.mdx Outdated
Simplify the encryption key setup wording and explain why a secret arg must be a
non-list string.

Relates to #2648
Registers the new "Hidden — you do not have permission to view this value" label
in the message catalogs. Non-source locales are left untranslated, to be filled
by the periodic bulk translation pass like other pending strings.

Relates to #2648
michaelbromley and others added 2 commits July 31, 2026 10:10
# Conflicts:
#	packages/dashboard/src/i18n/locales/ar.po
#	packages/dashboard/src/i18n/locales/bg.po
#	packages/dashboard/src/i18n/locales/cs.po
#	packages/dashboard/src/i18n/locales/de.po
#	packages/dashboard/src/i18n/locales/en.po
#	packages/dashboard/src/i18n/locales/es.po
#	packages/dashboard/src/i18n/locales/fa.po
#	packages/dashboard/src/i18n/locales/fr.po
#	packages/dashboard/src/i18n/locales/he.po
#	packages/dashboard/src/i18n/locales/hr.po
#	packages/dashboard/src/i18n/locales/hu.po
#	packages/dashboard/src/i18n/locales/it.po
#	packages/dashboard/src/i18n/locales/ja.po
#	packages/dashboard/src/i18n/locales/nb.po
#	packages/dashboard/src/i18n/locales/ne.po
#	packages/dashboard/src/i18n/locales/nl.po
#	packages/dashboard/src/i18n/locales/pl.po
#	packages/dashboard/src/i18n/locales/pt_BR.po
#	packages/dashboard/src/i18n/locales/pt_PT.po
#	packages/dashboard/src/i18n/locales/ro.po
#	packages/dashboard/src/i18n/locales/ru.po
#	packages/dashboard/src/i18n/locales/sv.po
#	packages/dashboard/src/i18n/locales/tr.po
#	packages/dashboard/src/i18n/locales/uk.po
#	packages/dashboard/src/i18n/locales/uz.po
#	packages/dashboard/src/i18n/locales/zh_Hans.po
#	packages/dashboard/src/i18n/locales/zh_Hant.po
…onfigs

The struct-field-to-custom-field-config mapping did not set `secret`, which became
a required property once the custom-field config type started selecting it. Struct
sub-fields cannot be secret, so it is set to false.

Relates to #2648
@sonarqubecloud

Copy link
Copy Markdown

@michaelbromley
michaelbromley merged commit 54e7c77 into minor Jul 31, 2026
35 checks passed
@michaelbromley
michaelbromley deleted the feat/secret-fields branch July 31, 2026 09:54
@vendure-ci-automation-bot vendure-ci-automation-bot Bot locked and limited conversation to collaborators Jul 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants